feat(provisioning): declarative realm provisioning (import / apply / prune / export + per-realm self-service)#107
Merged
Conversation
… runtime Adds IRealmProvisioningService.HardDeleteRealmAsync alongside the existing reversible soft-delete. Sequence (verified by integration test): MasterTableTenancy .RemoveTenantAsync (evict tenancy cache + dispose data source + delete the realms.mt_tenant_databases registry row) -> DROP DATABASE ... WITH (FORCE) (terminates the per-tenant async-daemon backend) -> remove the global Realm record + invalidate the realm cache. Guarded against the control-plane realm. RealmHardDeleteTests proves: the victim tenant DB is physically dropped, the global record is gone, a sibling realm + its DB are fully intact, and the control-plane realm is refused with its DB surviving. Known caveat (documented in code + Atlas): re-creating a realm with the SAME slug in the SAME process reuses Weasel's connection-string-cached NpgsqlDataSource (no per-key eviction). Unique slugs avoid it; a custom evictable INpgsqlDataSourceFactory is the clean fix if in-process slug reuse is ever needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… oauth + users) The in-process engine behind declarative realm provisioning. Maps a RealmManifest onto the EXISTING canonical operations — never reimplementing a mutation: realm shell via IRealmProvisioningService, settings via IRealmSettingsService, OAuth apis/scopes/ clients via OAuthAdminService, users via the CreateUserCommand Wolverine handler. Correctness: - Tenant routing: the realm shell is created against the global store, then per-tenant config runs under TenantContext.Enter(slug) + a fresh DI scope. TenantedSessionFactory prefers the AsyncLocal TenantContext over the ambient (control-plane) HttpContext, so direct-service writes land in the NEW realm even though the call runs on the CP host. - Wolverine commands resolve their Marten session from the message-envelope tenant, not TenantContext, so user creation uses InvokeForTenantAsync(slug, ...) (a plain InvokeAsync falls back to a tenant-less session and throws "Default tenant"). - All-or-nothing: any failure during apply hard-deletes the partially-provisioned realm. RealmManifestApplierTests proves an import lands the oauth config + user in the new realm's DB (verified via the canonical read methods), that it is isolated from the system tenant, and that a duplicate slug is rejected. v1 covers realm/settings/oauth/users; apps, roles, groups, login providers and the UpdateRealm merge come next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Moves the App-create logic (slug/displayName/duplicate validation + NormalizePermissions + StartStream) out of the AppsEndpoints MapPost lambda into a shared AppAdminService returning ErrorOr<App>. The endpoint now delegates and maps ErrorOr→IResult; the realm-provisioning applier will call the same service, so App creation has ONE canonical write path (the no-divergence invariant). Update/delete stay inline for now (their reference-checking is consolidated when the applier gains update via UpdateRealm). Behaviour-preserving: AppCatalogDeleteBlockTests stays green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… cross-references Redesigns RealmManifest to key-based cross-references (apps by slug, roles by key, permissions by resource:action) mirroring demo-seed.json; the applier resolves keys to ids in dependency order (apps → apis/scopes/clients → roles → users). Adds App and Role to the applier by reusing the now-shared AppAdminService / RoleAdminService (Role create extracted from RolesEndpoints; the realm:admin guard is a parameter, true for trusted control-plane provisioning). APIs/roles resolve their app's permission catalog by resource:action; clients resolve app links by slug. RealmManifestApplierTests imports a realm with an app+catalog, an app-linked API/scope, a confidential client, an app-scoped role (2 permissions), and a user — and verifies they land in the new realm's tenant DB (isolated from system). Groups deferred: CreateGroupHandler cascades a durable Wolverine message (membership recalculation) that InvokeForTenantAsync routes to the tenant DB, which has no Wolverine durability tables. Recorded in Atlas for a focused follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ession Completes entity coverage. Groups are created by calling the canonical CreateGroupHandler directly with the applier scope's plain (TenantedSessionFactory) IDocumentSession — NOT the Wolverine-outbox-enrolled session that InvokeForTenantAsync would supply. That avoids the durable-inbox auto-membership event forwarding (ReferenceSyncRegistration routes GroupCreatedEvent to a UseDurableInbox() local queue) which would otherwise try to write wolverine_incoming_envelopes in the fresh tenant DB, which has no Wolverine tables. This mirrors why user creation already worked (ASP.NET Identity's UserManager commits via a non-enrolled session). The skipped auto-membership sync is fine for provisioning — membership re-derives at login (LoginTimeMembershipDeriver). Member/role cross-references resolve by key (user key → id, role key → id). The test imports a group referencing a manifest user + role and asserts both resolved. Applier coverage is now complete: realm, settings, apps(+catalog), apis, scopes, clients, roles, users, groups — all via the canonical operations with all-or-nothing rollback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…de bodies
Extracts the App/Role update operations into the shared admin services so the
realm-provisioning applier and the admin API share one canonical write path:
- AppAdminService.UpdateAppAsync (ErrorOr<App>) — display name + catalog with the
catalog-delete reference block; the rich 409 blocker list rides through
Error.Metadata so AppsEndpoints renders the exact body AppDetails.vue consumes.
- RoleAdminService.UpdateRoleAsync (ErrorOr<PermissionRole>) — the realm:admin
guard is a parameter (endpoint passes the caller's status, applier passes true).
- FindReferencesAsync + the permission-reference shape move into AppAdminService;
the App-delete block reuses them. The duplicate endpoint-side NormalizePermissions
and BuildRoleAsync are removed.
Also fixes two latent regressions the earlier create-extraction introduced: the
App/Role endpoints routed errors through the shared ErrorOrExtensions.ToResult,
which drops the error code from the body and maps Forbidden to Results.Forbid() —
under this app's cookie auth that is an empty-body 403 for /api/*
(OnRedirectToAccessDenied). Both endpoints now render {Error,Message} with the
code in the body, restoring RealmAdminEscalationGuardTests (role create-forbidden
+ app reserved-permission), which were only green because the prior work ran the
provisioning filter, not the security suite.
1269 unit + 459 integration tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erge) Adds the full-update half of declarative realm provisioning. UpdateRealmAsync requires the slug to exist and upserts every manifest entity by natural key (app slug, api/scope/role/group name, client id, user email/username) through the SAME canonical Update op the admin API uses, creating it when absent. The realm DB is never dropped (that would discard signing keys, the OpenIddict token store and user subs), so this is a strict in-place merge — unlike import there is no all-or-nothing rollback; a mid-apply failure leaves earlier writes committed and is safe to re-apply. Field semantics: booleans are always applied; scalar strings and non-empty lists replace the stored value; an omitted/empty list and a null app-link leave the stored value unchanged (sets and changes, never clears or detaches — that stays an admin-API/Stage-2 op). App-catalog entry ids are preserved by resource:action so an unchanged permission keeps its id and doesn't trip the catalog-delete block. Client secrets are minted only at create. Two tenant-durability gotchas mirrored from import: user UPDATE goes through UpdateUserHandler on a PLAIN session (not the bus) because UserUpdatedEvent's durable ReferenceSync forwarding would write wolverine_*_envelopes tables the tenant DB lacks; groups likewise use plain Create/UpdateGroupHandler. Group member/role refs fall back to a DB lookup for entities not created this run. Entity-level prune (removing entities absent from the manifest) is deliberately out of scope (Stage 2). RealmManifestApplierTests: import then update a realm that changes every entity and adds a new role; asserts the updates land, ids stay stable (in-place), and a missing slug is rejected (Realm.NotFound). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-delete
Exposes the RealmManifestApplier over the existing control-plane-gated
/api/admin/realms group:
- POST /import — provision a brand-new realm from a full manifest (201 + slug,
primary domain, and the plaintext client secrets available only at create).
- POST /{slug}/apply — in-place merge/upsert against an existing realm; the route
slug must match the manifest realm slug (400 Manifest.SlugMismatch otherwise).
- DELETE /{slug}?hard=true — escalates the existing soft-delete to the prod-safe
hard delete that DROPs the tenant DB; default false keeps soft-delete behaviour.
Manifest errors render {Error,Message} with the code in the body
(Realm.AlreadyExists / Realm.NotFound / Manifest.*) so a test-kit can distinguish
outcomes — not collapsed through the shared ToResult.
RealmProvisioningEndpointsTests drives the import->apply->hard-delete round trip
plus the duplicate-409 / missing-404 / slug-mismatch-400 paths against an isolated
cold host. Export (GET /{slug}/export) is deferred — it needs a secrets round-trip
design (hashes can't re-import through the canonical create ops) and isn't on the
test-kit's critical path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…test Adds a standalone, dependency-light NuGet-able test-kit so consumer-app integration tests can spin up a real, isolated Modgud realm per test from a declarative manifest: - ModgudProvisioningClient(HttpClient) wraps the control-plane provisioning API. ImportRealmAsync(manifest) returns a ProvisionedRealm handle exposing Authority, PrimaryDomain, and the freshly minted client secrets (SecretFor). ApplyAsync does an in-place merge; DisposeAsync hard-deletes the realm (drops the tenant DB), so `await using` gives automatic teardown. Server error codes (Realm.AlreadyExists, Realm.NotFound, Manifest.SlugMismatch) surface as ModgudProvisioningException.Code. - The kit ships its own manifest POCOs (the client-side mirror of the server contract) so it carries zero server dependencies. ProvisioningTestKitTests serialises those POCOs against the live import/apply/delete endpoints, so any drift between the kit's shape and the server's manifest contract fails there. RealmManifestParityTests is the drift-guard: it builds the same OAuth client two ways — via OAuthAdminService.CreateClientAsync with an explicit DTO (the admin-API path) and via a manifest import — and asserts the projected client shape (type, consent, redirects, grants, permissions, enabled, app-links-by-slug) is identical. Both go through the same canonical service, so a mismatch can only mean the applier's manifest->DTO mapping drifted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the get → edit → "set a password" → apply round-trip the operator workflow
needs, without ever exposing stored credentials.
Export (GET /api/admin/realms/{slug}/export, control-plane realm:read):
- RealmManifestExporter is the inverse of the applier — reads the realm's current state and
emits a RealmManifest, reversing cross-references back to keys (app slug, role/user key,
resource:action). STRUCTURE-ONLY: never emits client secrets or password hashes (they're
one-way; a re-import generates fresh secrets / leaves passwords untouched). Omits entities
that can't cleanly re-apply: auto-seeded standard OIDC scopes and system apps, plus
service-account-linked clients (the manifest doesn't model service accounts). Settings are
not exported yet — re-applying with no Settings leaves them untouched.
Set-password on apply:
- Extracts the admin set/reset-password logic into the canonical SetUserPasswordHandler
(the PUT /api/user/{id}/password endpoint now delegates to it, preserving 200 + the
RevokeAllAccessAsync kill-switch). The applier's update path calls it when a manifest
carries a Password on an EXISTING user — so you can export (passwordless), drop a password
on a user, and apply to make them able to log in. New users already get theirs at create.
RealmManifestExportTests: import (passwordless user + confidential client) → export →
assert no secret/password/seeded-entity leaks → re-apply unedited (idempotent) → set the
user's password in the export → apply → the user now has a password. Plus an export-endpoint
HTTP test asserting the client secret is omitted from the response.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends RealmManifestExporter to emit manifest.Settings — all nine realm-settings sections (self-registration, registration-fields, native grants, branding, auth rate limits, deletion, audit, DCR, CIMD) reverse-mapped from the read shape to the patch shape at their current values, so an export shows the full config and you can decide what to change. The write-only captcha secret is intentionally omitted (only a CaptchaSecretSet flag is readable, never the plaintext); re-applying leaves the stored secret untouched. Import/apply already consume manifest.Settings, so settings now round-trip end-to-end. RealmManifestExportTests: asserts Settings is exported (default RegistrationFields value, captcha secret null), then edits RegistrationFields.Username to Required, applies, and re-exports to confirm it round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the OAuth-entity (api/scope/client) bool flags nullable in the manifest so a partial apply only changes what's present: an omitted bool is "no change" on update and the shipped default on create (Enabled/ShowInDiscoveryDocument default true, the rest false). Previously a manifest bool always carried its default, so sending a partial entity to change one field could silently flip e.g. a disabled client back on. This is the JSON-wire form of Optional<T>: for value types bool? is identical, and the manifest is bound directly as the HTTP body — matching how the codebase's own partial-update endpoint (ProfileEndpoints) keeps its wire DTO nullable and uses Optional<T> only for internal representations (binding Optional<T> from the body would force AddOptionalAware onto the global resolver). Strings already patch via null=no-change and lists via empty=no-change; only the always-applied bools needed fixing. The TestKit's manifest POCO mirrors the nullable bools (wire-compatible). Applier import and the update-create branch coalesce to defaults; the update path passes the nullable straight into the already-nullable Update DTOs. RealmManifestApplierTests: import a disabled client, apply a partial update that changes the redirect and omits Enabled — the client stays disabled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…euse
Stage 2 (prune) prep: the applier must reuse the SAME delete op the admin
API uses, per the single-canonical-write-path invariant. Three delete paths
that lived inline in endpoints are now consolidated:
- AppAdminService.DeleteAppAsync — system-app guard + the App-level reference
block (roles/RSes linked directly or via the catalog). The rich blocker
payload rides through Error.Metadata so AppsEndpoints renders the exact
App.HasReferences 409 body AppDetails.vue consumes (now test-pinned — it
had zero coverage before).
- RoleAdminService.DeleteRoleAsync — load + PermissionRoleDeletedEvent.
- DeleteGroupCommand/DeleteGroupHandler (Modgud.Authorization.Commands) —
mirrors create/update; the endpoint invokes it on the bus. The applier
will construct the handler directly on a PLAIN tenant session (GroupDeleted
has a durable ReferenceSync forwarder — same tenant-DB-outbox trap as
create/update groups).
Endpoints delegate and render coded {Error,Message} bodies via their local
ToErrorResult (not the shared ToResult, which drops the code / collapses
Forbidden to an empty 403 — the §7 lesson). Behaviour-preserving.
Tests: App delete-block (unreferenced→204, system→400, role-referenced→409
with the full blocker shape) + role/group delete endpoints (group delete
proves Wolverine discovers DeleteGroupHandler at runtime).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 2: UpdateRealm gains an opt-in prune. Without it the additive merge is unchanged; with ?prune=true the apply becomes a full sync (k8s apply --prune) — after the upsert, every entity in the realm but absent from the manifest is deleted via its canonical delete op (the ones consolidated in the previous commit), in reverse-dependency order so a dependent is gone before the app/role it points at: clients → scopes → apis → groups → users → roles → apps. A still- referenced app correctly errors. Lockout + infrastructure protection (the robust superset of "System + last admin" — protect ALL admins so no manifest can lock the realm out): NEVER pruned — the system app, auto-seeded standard scopes, SA-linked clients, any realm-admin role, any user who currently holds realm:admin, and any group that confers realm:admin (else pruning an admin's group silently strips their admin path even though the role + user survive). The protection checks run AFTER the upsert so they see the realm's desired post-merge role graph. Tenant durability (same trap as create/update): user delete via DeleteUsersHandler and group delete via DeleteGroupHandler run on the PLAIN tenant session, not the bus — their events have durable ReferenceSync forwarders that would hit wolverine_*_envelopes a tenant DB lacks. Also fixes a real divergence the prune test surfaced: group CREATE now mirrors the create endpoint's `BoundTo ?? [modgud]` default (CreateGroupHandler itself defaults null → [] = dormant), so a manifest-provisioned admin group actually confers its roles instead of silently granting nothing. GroupMembershipGuards made public so the prune can reuse GroupConfersRealmAdminAsync. Tests (cold-start): prune removes absent client/scope/api/group/user/role/app together (reverse-order) while protecting the system app, standard scopes, and the full realm-admin path (role + admin-conferring group + admin user keeps realm:admin); ?prune=true endpoint wiring prunes an absent client over HTTP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dev-docs design-of-record for the feature: the endpoint surface, the single-canonical-write-path invariant, the manifest schema, apply=patch field semantics (nullable bools / null-string-no-change / empty-list-no-change / catalog-id preservation / set-password-on-apply), prune (full sync) with the lockout + infrastructure protection rules, the structure-only export + secrets stance, and the load-bearing tenant-durability gotchas (TenantContext routing, plain-vs-bus sessions, the group BoundTo default). Registered in the dev-docs sidebar + linked from the future-features index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/admin/realms/manifest-schema returns the JSON Schema for the import/apply body, so a consumer (or an agent) can fetch the contract and author a valid manifest without reading the source. - Generated from the live RealmManifest type via JsonSchemaExporter using the API's own JsonSerializerOptions, so property casing + nullability always match the wire contract (can't drift). required: ["Realm"]; the entity lists default to empty. - Per-field docs ride along: every manifest property/record carries a [Description] (keys-not-ids, resource:action, the BoundTo lockout note, nullable-bool patch semantics, …) which the exporter copies into each node's `description`. A worked example is attached at the root. - Gated with realm:write on the control-plane app — the SAME permission as import/apply, so only a caller who could apply a manifest may fetch its schema (not realm:read). Tests: admin gets the described schema + example; an unauthenticated caller is denied. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dev/app-testing/ — a self-contained stack (Postgres + the locally-built modgud:local image, which carries the provisioning feature that isn't on :beta) on isolated ports, plus a README recipe so another local app's integration tests can provision a throwaway realm per run via the control-plane API / TestKit and hard-delete it after. Documents the one-time bootstrap-admin step, the cookie-auth + TestKit flow, where to fetch the manifest schema, and the host-routing caveat for driving real OAuth flows against a provisioned realm. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Public/in-app docs (docs/) so humans AND agents discover the feature and learn the contract: - New admin/realm-provisioning.md — what it is + why (realm-as-code, per-test realms, agent automation), the endpoint table, where to fetch the JSON Schema (GET /manifest-schema, realm:write), the manifest at a glance (keys-not-ids, resource:action), a curl quickstart, merge-vs-prune, structure-only export, the TestKit, and the host-routing caveat for OAuth flows. - Registered in the docs sidebar (Realm group) + linked from the admin index and the Realms page. - reference/realm-api.md: added the import / apply / apply?prune / export / manifest-schema rows and the ?hard=true note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…plane) A second declarative-config surface for delegating ONE realm to its own admin — without control-plane powers. New RealmConfigEndpoints at /api/admin/realm-config/* (apply / export / manifest-schema), reusing the same RealmManifestApplier / RealmManifestExporter as the control plane; only the entry point + gate differ. - Runs on the realm's OWN host (not control-plane-filtered), gated by realm:admin in the current realm (works for a user OR a service-account token holding realm:admin). - Scope = TenantContext.Current: the endpoint pins the manifest to the current realm; a manifest targeting a different slug is refused (Manifest.SlugMismatch). No import, no realm-delete — realm lifecycle stays control-plane-only. apply supports ?prune=true bounded to the realm, with the same lockout/infra protections. - So an operator can: create a realm (control plane), grant a principal realm:admin in it, and hand that credential off — it can fully manage that one realm's config + entities and nothing else. Tests (cold-start): apply manages the current realm + export round-trips; a foreign slug is rejected (400); the surface is gated for an unauthenticated caller. Docs: the admin guide now contrasts the two surfaces and documents delegation; reference + the dev-docs note updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a TL;DR at the top of the app-testing README for the common case where an agent is handed an already-running instance: base URL, control-plane admin creds, and the 5-step login → fetch-schema → import → test → delete flow, with pointers to the TestKit recipe and the OAuth host-routing caveat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ists The TL;DR claimed a running instance at a fixed URL + hardcoded creds — that documents the current machine's state, which is wrong on any other box. Replace it with a state-neutral "At a glance": (1) get an instance running (build/run/bootstrap, §1), (2) the per-test flow. The bootstrap step now points at docs/getting-started/first-time-setup (the canonical recovery-CLI source) and notes the creds are an example, not a given. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Declarative realm provisioning — stand up, update, or tear down a complete realm
(apps, OAuth APIs/scopes/clients, roles, users, groups, settings) from a single JSON
manifest, at runtime, by reusing the exact operations the admin UI already uses.
Realm-as-code — for reproducible setups, fast per-test realms, and agent automation.
Built on one governing invariant: exactly one canonical write path per mutation. The
applier reimplements nothing — each manifest entity is dispatched to the same application
operation as the manual admin path, so the two can never drift.
Two surfaces
/api/admin/realms/*/api/admin/realm-config/*realm:writeon thecontrol-planeapprealm:adminin that realmThe per-realm surface lets an operator hand one realm to an app team or agent (a user
or a service account holding
realm:adminin that realm) to fully manage that realm'sconfig + entities — without any control-plane power.
Endpoints
POST /api/admin/realms/import— create a new realm (all-or-nothing; rolls back via hard-delete on failure).POST /api/admin/realms/{slug}/apply— in-place merge/upsert (never drops the DB).?prune=true= full sync.GET /api/admin/realms/{slug}/export— structure-only manifest (never secrets / password hashes).GET /api/admin/realms/manifest-schema— the JSON Schema (generated from the live type, per-field descriptions + example).DELETE /api/admin/realms/{slug}?hard=true— prod-safe hard-delete (drops the tenant database).GET|POST /api/admin/realm-config/{manifest-schema,export,apply}— the per-realm (data-plane) surface, gatedrealm:admin.Plus
Modgud.Provisioning.TestKit— a standalone, zero-server-dep NuGet client(
ImportRealmAsync→ProvisionedRealmwithSecretFor/ApplyAsync/ dispose→hard-delete).Notable design points
RemoveTenantAsync→DROP DATABASE … WITH (FORCE)),control-plane-guarded. The risk gate of the whole feature.
UpdateRealmis an in-place merge, never Remove+Import — dropping the DB would kill signingkeys, the OpenIddict token store, and change every user's
sub.service-account-linked clients, or any
realm:adminpath (realm-admin role, an admin user, or anadmin-conferring group). Bounded to the realm.
app-catalog ids preserved across updates; client secrets minted only at create.
session (not the Wolverine outbox), since their events have durable
ReferenceSyncforwarders.Security
The authorization model was reviewed and is sound: the per-realm
realm:admingate is evaluatedagainst the current (host-routed) realm via the request-scoped, tenant-bound permission service;
auth cookies are encrypted with per-realm DataProtection keys (an X cookie can't decrypt on Y);
the control-plane surface is 404 on tenant hosts.
applypins the manifest to the current realm andrejects a foreign slug. No new privilege (apply = the bulk form of what
realm:adminalready does inits realm). The image is secure-by-default (Production env, fail-closed boot guards); the dev compose's
loose config is opt-in via env vars.
One documented, pre-existing follow-up (not introduced or worsened here): OAuth client delete
doesn't revoke the client's already-issued tokens — Atlas
engineering/oauth-client-delete-token-revocationhas the full fix recipe.Tests & docs
the per-realm
realm-configendpoints, export round-trip, the manifest schema, the TestKit, and amanifest↔admin-DTO parity guard; plus delete-op endpoint tests. Full suite green (1269 unit + 483 integration).
docs/admin/realm-provisioning.md(two surfaces + delegation), reference table,admin index + realms cross-links. Design-of-record in
dev-docs/future-features/.dev/app-testing/(compose + recipe) for spinning up a throwawayModgud per test run.
🤖 Generated with Claude Code